Recap: 昨天把 log 盤點完,裡面有模型 404、有 Day 18 跑 eval 撞到的 429,還有 Day 13 被花費上限擋下、畫面卻只寫「模型拒絕了這個請求」的那次
這幾天欠下的帳都是同一件事:出錯的時候,程式不知道這是哪一種錯
不知道是哪一種,就不知道該不該重試,也不知道該跟使用者說什麼
Day 04 寫的第一個測試
def test_resume_too_short_is_rejected_before_calling_model():
r = client.post("/analyze", json={"resume_text": "太短"})
assert r.status_code == 422
min_length=20 讓明顯無效的輸入在 pydantic 那層就被擋掉,不會變成一次付費呼叫
今天要處理的是另一種:請求送出去之後才失敗的
google-genai 的原始碼裡有這段
# Default retry options.
# By default, the client will retry 4 times with approximately 1.0, 2.0, 4.0,
# 8.0 seconds between each attempt.
_RETRY_ATTEMPTS = 5 # including the initial call.
看起來預設就會重試 4 次,但往下看真正決定要不要重試的函式
def retry_args(options: Optional[HttpRetryOptions]) -> _common.StringDict:
if options is None:
return {'stop': tenacity.stop_after_attempt(1), 'reraise': True}
沒給 retry_options 就是只打一次
上面那個「重試 4 次」,是你給了一個空的 HttpRetryOptions() 時的預設值
ADK 也一樣,LlmAgent(model="gemini-2.5-flash") 會變成一個 Gemini 物件,它的 retry_options 預設是 None,而且沒有設逾時
所以到今天為止,這個專案的每一次模型呼叫,都是失敗一次就放棄
Day 18 跑 eval 撞到 429,eval 自己包了一層「等 20 秒、再等 60 秒」才跑得完,原因就在這裡
另外一個沒注意到的:llm.py 只接了 ClientError 跟 ServerError
逾時、連不上是 httpx 的例外,不是 APIError,以前根本沒接,會直接變成 500
要知道重試到底怎麼動,得讓它失敗
404 跟逾時可以用真的 Vertex 觸發,但 429 不是說要就有
所以寫了 scripts/trigger_failures.py:在本機起一個假的模型端點,照劇本回 429、503、403,或故意很慢
用的是真的 google-genai client、真的 HTTP,重試走的是跟正式呼叫同一段程式,而且不花錢
--- SDK 預設(沒給 retry_options)
429 一次就好 打了 1 次 間隔 - 共 0.0s 失敗 → rate_limited
--- 加上 RETRY
429、429、成功 打了 3 次 間隔 ['2.1', '4.6'] 共 6.8s 成功
一直 429 打了 3 次 間隔 ['2.0', '5.0'] 共 7.0s 失敗 → rate_limited
503、成功 打了 2 次 間隔 ['2.9'] 共 2.9s 成功
403 花費上限 打了 1 次 間隔 - 共 0.0s 失敗 → spend_cap
400 參數錯 打了 1 次 間隔 - 共 0.0s 失敗 → bad_request
每次都要 3 秒、逾時 1 秒 打了 3 次 間隔 ['3.8', '5.5'] 共 10.4s 失敗 → timeout
最後一行是今天最重要的發現http_status_codes 裡我只放了 429 跟 5xx,但逾時還是被重試了 3 次
回去看 SDK 的判斷條件
retry = tenacity.retry_if_exception(
lambda e: (isinstance(e, errors.APIError) and e.code in retriable_codes)
or isinstance(e, _HTTPX_TRANSIENT_EXC), # TimeoutException、ConnectError
)
狀態碼可以選,逾時跟連線錯誤是寫死的,一定重試
所以單次逾時設多長,最壞情況就要乘以 3
再用真的 Vertex 打兩個
不存在的模型 共 2.9s 失敗 → model_not_found(ClientError)
逾時 2 秒的長回答 共 14.0s 失敗 → timeout(ReadTimeout)
404 沒有重試,馬上回來
逾時 2 秒的那個,SDK 的 log 印了兩次 Retrying ... as it raised ReadTimeout,總共打了 3 次
另外要記得:client 等不及斷線,不代表伺服器那邊停止生成
那 3 次被放棄的請求,模型端有沒有照算 token,從 client 這邊看不到,我也沒辦法確認
逾時重試可能是「付三次錢、一次都沒拿到」
| 錯誤 | 重試? | 為什麼 |
|---|---|---|
| 429 配額滿了 | 要 | 共用額度,等幾秒常常就好(Day 07 就遇過,重跑就過) |
| 500、502、503、504 | 要 | 模型端暫時的狀況 |
| 逾時、連不上 | SDK 一定會重試 | 只能靠把逾時設短來控制代價 |
| 400 參數錯 | 不要 | 同一個請求送幾次都一樣 |
| 403 權限、花費上限 | 不要 | 要有人去改設定或預算 |
| 404 模型不存在 | 不要 | Day 19 的 asia-east1,是設定錯 |
| 輸出不符合 schema | 不要 | 有機會重試就好,但那是 prompt 的問題,重試只是把它藏起來 |
全部收在 app/services/retry.py
RETRY_CODES = [429, 500, 502, 503, 504]
RETRY = types.HttpRetryOptions(
attempts=3,
initial_delay=2.0,
max_delay=10.0,
exp_base=2,
jitter=1,
http_status_codes=RETRY_CODES,
)
TIMEOUT_MS = 60_000
為什麼是 3 次、從 2 秒開始:這是給有人在畫面前等的請求用的
多等 6 秒可以接受;Day 18 那種要等 20 秒、60 秒才恢復的 429,在一個請求裡硬撐只會讓使用者以為當掉了
那種情況應該直接告訴他「一分鐘後再試」
批次的 eval 不一樣,它等得起,所以 evals/injection.py 外面那層 20 秒、60 秒的退避留著
逾時從 120 秒改成 60 秒:Day 13 量到完整流程 6 次呼叫 67 秒,單次 10 幾秒
今天再跑一次完整流程,5 次呼叫 60.9 秒,全部成功,60 秒不會誤殺正常的請求
但最壞情況是 60 × 3 + 重試間隔,大約 3 分鐘
這個專案有好幾層:FastAPI → agent 的一輪對話 → 每一次模型呼叫
一開始想在 FastAPI 那邊包一層「失敗就整輪重來」,寫測試的時候先確認了一件事
def test_failed_turn_leaves_the_message_in_the_session():
...
assert err.failure.kind == "rate_limited"
assert len(users) == 1
模型一直 429、整輪失敗了,但使用者那句話已經寫進 session
整輪重來的話,歷史裡就有兩句一樣的話;如果失敗發生在第三次工具呼叫之後,前面的工具也會再跑一次
一輪對話不是冪等的,不能隨便重來
而且兩層都重試的話,次數是相乘的:外面 3 次 × 裡面 3 次,最壞是 9 次模型呼叫
所以重試只放在最底層,單次模型呼叫
那一次失敗就是沒發生,重送一次沒有副作用
return LlmAgent(
name="career_advisor",
model=model or Gemini(model=s.model, retry_options=RETRY),
generate_content_config=types.GenerateContentConfig(
http_options=types.HttpOptions(timeout=TIMEOUT_MS)
),
...
)
本機的 llm.py、analyzer.py 用同一組 RETRY 跟 TIMEOUT_MS
agent 也拿假端點驗一次(Gemini 有 base_url 可以指過去)
模型 429、429、成功 共 8.1s 成功 假端點被打了 3 次
模型一直 429 共 8.4s 失敗 → rate_limited 假端點被打了 3 次
Day 13 的問題是:403 一律寫成「模型拒絕了這個請求」
花費上限到了是 403,權限設錯也是 403,使用者分不出來是「等一下就好」還是「今天都不會好」
classify() 把例外分成幾種,每一種有自己的 HTTP 狀態跟說法
if code == 429:
return Failure(
"rate_limited", 503,
f"模型那邊現在請求太多,已自動重試 {RETRIES} 次還是滿的。請一分鐘後再試。",
retry_after_s=60,
)
...
if code == 403 and _SPEND_CAP.search(str(e)):
return Failure("spend_cap", 503, "這個服務的費用上限已經用完,今天之內不會恢復,再試也一樣。")
重點在 retry_after_s
等一下再試有用的,HTTP 回應帶 Retry-After;再試也一樣的,就不帶
前端看有沒有這個 header,決定顯示黃色的「約 60 秒後可再試」,還是紅色的錯誤
raise HTTPException(status_code=e.status_code, detail=str(e), headers=e.headers)
順便修正 Day 18 的一個誤會
當時 eval 的註解寫「ADK 的 429 是私有類別,只能看訊息」
其實 _ResourceExhaustedError 繼承 ClientError,e.code 就是 429,現在寫成測試了
反而是今天改完之後,eval 那層真的會壞:AgentError 的訊息換成給人看的中文,裡面沒有 429 這個字了
eval 改成看 e.failure.kind == "rate_limited",不再比對字串
固定流程一次分析要呼叫 5、6 次模型:解析履歷、解析職缺、對照、澄清、改寫、計畫
以前任何一步失敗,整個請求就是一個錯誤,前面花的 token 全部白花
但不是每一步都一樣重要
def failed(key: str, e: LlmError) -> None:
report.stopped = f"{OPTIONAL_STEPS[key]}沒有完成:{e}"
report.skipped.append(OPTIONAL_STEPS[key])
哪一步失敗,後面的也不做
429、花費上限這種錯,下一步多半也一樣失敗,繼續做只是讓使用者再多等一輪重試
再加一個總時間的上限
# 正常是 67 秒、6 次呼叫(Day 13)。每次呼叫最壞是 60 秒 × 3 次,
# 不設總上限的話,6 步全部碰到逾時要等十幾分鐘。
BUDGET_S = 120
超過 120 秒就不再開始下一步,已經做完的交出去,畫面上寫清楚少了哪幾塊
agent 的三個工具都是確定性的程式(Day 15),理論上不會壞
但「理論上」不夠,例如資源清單讀不到,lookup_resources 就會丟例外
先看 ADK 遇到工具丟例外會怎樣
except Exception as tool_error:
error_response = await _run_on_tool_error_callbacks(...)
if error_response is not None:
function_response = error_response
else:
raise tool_error
沒有掛 on_tool_error_callback,例外就往外丟,整輪對話直接結束
模型前面做的事全部白做,使用者只看到 502
模型叫了一個不存在的工具名稱,也是一樣的下場
確定性的工具不重試
這是跟模型呼叫最大的不同:同樣的參數再叫一次,結果一樣是失敗
所以工具失敗時不重試,而是把失敗當成工具的回傳值交給模型,讓它決定怎麼跟使用者說
def tool_failed(tool, args, tool_context, error) -> dict:
n = tool_context.state.get(_TOOL_ERRORS, 0) + 1
tool_context.state[_TOOL_ERRORS] = n
return {
TOOL_ERROR_KEY: f"工具 {tool.name} 執行失敗({type(error).__name__}),這不是查無資料,是系統暫時出錯。",
"instruction": "不要用同樣的參數再呼叫一次,結果會一樣。...",
}
回給模型的只有工具名稱跟例外的型別,不放例外訊息
工具的參數是履歷原句(Day 22),例外訊息可能把它們印出來
停止條件
模型不一定聽話,它可能還是一直叫同一個壞掉的工具
所以在 Day 15 的呼叫次數上限旁邊,加一條:一輪裡工具失敗 2 次,下一次就不呼叫模型了
def limit_llm_calls(callback_context, llm_request):
if callback_context.state.get(_TOOL_ERRORS, 0) >= MAX_TOOL_ERRORS:
return _notice(TOOL_ERROR_NOTICE, "tool_errors")
...
計數放在 temp: 開頭的 state,下一句話重新算
用假端點讓模型一直叫壞掉的工具、一直叫不存在的工具
工具一直丟例外,模型一直叫 共 1.0s tools=['lookup_resources', 'lookup_resources'] stopped='這一輪有工具連續執行失敗,已中止。…'
假端點被打了 2 次
模型叫了不存在的工具 共 1.2s tools=['search_web', 'search_web'] stopped='這一輪有工具連續執行失敗,已中止。…'
假端點被打了 2 次
以前這兩種都是一個 502,現在是一段說明,而且只呼叫了 2 次模型
假模型只驗得了機制,真的模型拿到錯誤會怎麼跟使用者說,要用真的跑
把 lookup_resources 弄壞,問「我想補 Docker,請推薦學習資源,排一個四週計畫」
第一版的回傳值只寫「請告訴使用者這一部分目前無法用工具驗證」,模型的回覆
很抱歉,目前沒有查到關於 Docker 的學習資源。
「工具壞了」被講成「查不到」
使用者讀到這句,會以為真的沒有 Docker 的資源,而不是等一下再問就好
這跟 Day 09 說的「沒寫到不等於不會」是同一件事:沒查到不等於沒有
把回傳值改成明講「這不是查無資料,是系統暫時出錯,不可以說成查不到或沒有」,再跑三次
很抱歉,目前系統暫時無法查詢到 Docker 的學習資源。請稍後再試。
很抱歉,查詢 Docker 學習資源的功能目前發生系統錯誤,暫時無法完成。您可以稍後再試。
由於目前無法查詢到學習資源,我也無法為您規劃四週的學習計畫。
很抱歉,因為系統暫時出錯,目前無法為您查詢 Docker 相關的學習資源,您可以稍後再試。
不過,我可以為您規劃一個四週的 Docker 學習計畫……(計畫有經過 validate_learning_plan)
三次都講對了
但三次只能說「這次講對了」,不能說「以後都會講對」,所以跟 Day 18 的 unverified_quotes 一樣,不靠模型的措辭
runner 從工具的回傳值裡認出 tool_error,放進 tool_errors 回給前端,畫面直接標示「這一輪有工具執行失敗,不代表查不到」
Retry-After:黃色提示「暫時無法完成(約 60 秒後可再試)」,問題放回輸入框Retry-After:紅色錯誤,再按一次也一樣真的打 Vertex 的逾時測試,第一次跑花了 36.9 秒,而且是 ConnectTimeout
逾時 2 秒、3 次、間隔 2 跟 4 秒,算起來應該 12 秒左右
第二次跑就是正常的 14.0 秒、3 次 ReadTimeoutHttpOptions.timeout 管的是單次 HTTP 請求,取得憑證之類的事不一定算在裡面,我猜是第一次取 token 比較慢,但沒有再追下去
能確定的是:「逾時 × 次數」是 SDK 管得到的部分,不是使用者實際會等的上限
這也是固定流程要另外設 BUDGET_S、雲端那條路 Day 21 的 180 秒要留著的原因
google_genai._api_client 的 INFO),沒有進結構化的紀錄。要知道「每天有多少請求是靠重試救回來的」,這個數字要記下來測試從 83 個變成 109 個
SDK 的重試用假端點驗,走真的 HTTP;agent 的工具失敗用 Day 18 的假模型跑真的 ADK Runner;全部不打模型
版本:google-adk 2.8.0、google-genai 2.22.0、gemini-2.5-flash
第 5 章到這裡結束:agent 上了雲端,看得到它做了什麼,出錯的時候也知道該不該再試
但「沒出錯」不等於「建議是好的」
明天開始第 6 章,先回答一個一直跳過的問題:怎樣才算好建議?